Skip to content

feat: attach image drives to warm starts - #398

Merged
yingdi-shan merged 1 commit into
kvcache-ai:mainfrom
Primary33:feat/image-mounts
Oct 26, 2026
Merged

yingdi-shan merged 1 commit into
kvcache-ai:mainfrom
Primary33:feat/image-mounts

Conversation

@Primary33

@Primary33 Primary33 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What

Add attachedDrives to POST /v2/sandboxes, so warm starts can attach image drives the same way cold starts do. aenv start --image-mount PATH=IMAGE (writable) and --image-mount-ro PATH=IMAGE build attachedDrives entries for both warm and cold starts; CLI warm starts now use v2.

Why

Mount models, datasets, or code from an image without managing persistent volumes or building a template for each combination.

Related issue

Requested in a maintainer discussion. Builds on merged #405. Replaces the earlier imageMounts design in this PR, following the review.

Scope and non-goals

  • Reuses the existing AttachedDrive schema, defaults, and resolve_attached_drives; the cold-start path is unchanged.
  • No new imageMounts API, no catalog records, and no sweep task.
  • Also fixes read-only volumes on cold start: they are mounted through envd with ro,noload after boot instead of by init.

Design and behavior changes

  • ExtraDrive.volume: bool becomes ExtraDriveKind { Physical, LaunchSlot, Volume }. Drives added at a snapshot launch are LaunchSlot and take reserved Firecracker slots ahead of volumes.
  • On capture, LaunchSlot drives are committed to the manifest like physical drives but keep their slots, because the VM state records them under the slot device IDs. physical_extra_drive_count now counts only drives with their own device IDs; the attached drives after them occupy the first slots, and launch-time drives use the slots that follow.
  • Relaunch, fork, and pause/resume restore these drives from the snapshot without extra code. Slot drives already in the VM state are bound before snapshot load; drives new to this launch are bound after load and sized like a fresh boot (diskSizeMB is the target size).
  • Snapshots with volumes or attached drives reject explicit attachedDrives, including []. volumeMounts keeps the fix: reject explicit volume mounts on snapshots with volumes #405 rule. Attached drives plus explicit volumes must fit in the snapshot's free slots, and attached drive paths must not overlap volume mount paths (HTTP 400).

Compatibility and operations

  • Public API or generated protocol: optional attachedDrives on NewSandboxV2; regenerated models.
  • Configuration or defaults: none. readOnly still defaults to true in the API; --image-mount sends false.
  • Snapshot manifest, artifact layout, or storage format: no new fields. physical_extra_drive_count may now be smaller than the number of attached drives. Persisted ExtraDrive keeps the boolean volume field for physical drives and volumes; LaunchSlot is written as "launch_slot".
  • Upgrade and rollback: older servers misplace slot drives in new snapshots and fail to parse paused sandboxes with LaunchSlot drives; upgrade servers and the CLI together.
  • Host requirements, permissions, ports, or dependencies: none.

Validation

Validated on Ubuntu 24.04 as a non-root user with isolated state and dependencies.

  • make fmt
  • make clippy
  • make test-unit
  • Relevant Rust integration tests
  • make -C services test (required when services/ changes)
  • Generated clients/server regenerated with the documented make target
  • Documentation updated
  • Benchmarks or performance comparison completed

Commands and results:

make agentenv-server            # OpenAPI Generator 7.22.0; NewSandboxV2 output unchanged
make fmt && make clippy         # pass
make test-unit                  # 1218 passed, 0 failed; capability/install script checks pass
cargo test -p agentenv --test integration snapshot_attached_drive   # 4 passed
make test-agent-integration     # integration 22 passed, 1 ignored; orchestrator 2 passed; OSS E2E 9 passed
scripts/tests/e2e/suites/15_volume.sh   # 56 passed

New integration test launch_time_attached_drive_keeps_its_slot_across_captures covers launch-time attach, capture, relaunch, fork, and a second capture.

Skipped checks and reasons:

  • make -C services test: services/ is unchanged.
  • 16_volume_randomized.sh: needs a multi-node Compose deployment.
  • posix_startup_pack_publish_resolve_and_resume: ignored by default; needs startup-pack configuration.

Risks and reviewer notes

Slot bookkeeping is the main risk: see FirecrackerSandbox::snapshot_config_for_launch, prepare_resume_drives, and the manifest count in pause_to_dir (src/sandbox/firecracker/sandbox.rs), plus SandboxSnapshotManifest::extra_drives and free_drive_slots. API validation is in create_sandbox_from_snapshot (src/api/impls/sandbox.rs); the v1 sandboxes_post body moved there unchanged apart from the new checks. Warm-start attached drives are not added to MMDS imageConfigs.

Checklist

  • The PR contains one coherent change and no unrelated formatting or refactoring.
  • New behavior is covered by tests, or I explained why testing is impractical.
  • Logs and examples contain no credentials, tokens, or private registry information.
  • I did not manually edit generated code without updating its source and regenerating it.

Copilot AI balanced review requested due to automatic review settings October 20, 2026 04:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Image-capacity handling and mixed-mount deletion have unresolved correctness issues, and unsupported OCI mounts perform unnecessary conversion.

3 open issues
What changed in this PR

Adds image filesystem mounts to sandbox starts, reusing volume infrastructure for lifecycle management, snapshots, and forks.

Changes:

  • Adds imageMounts API fields and read-only/writable CLI flags.
  • Introduces sandbox-owned volumes with restore and cleanup handling.
  • Fixes cold-start read-only volume mounting and adds tests and documentation.
File Description
tests/​integration/​orchestrator.rs Updates request fixtures.
src/​volume/​mod.rs Tracks sandbox-owned volumes and inheritance.
src/​volume/​catalog.rs Updates volume fixtures.
src/​types/​mod.rs Exports image-mount metadata.
src/​types/​image_mount.rs Defines image-mount metadata.
src/​snapshot/​types/​snapshot.rs Persists image-mount markers.
src/​sandbox/​firecracker/​sandbox.rs Mounts read-only volumes after boot.
src/​orchestrator/​types.rs Extends creation requests.
src/​orchestrator/​tests.rs Tests scoped-volume deletion.
src/​orchestrator/​store/​metadata.rs Tracks and classifies mounts.
src/​orchestrator/​service.rs Adds scoped-volume cleanup.
src/​image/​resolver.rs Exposes published image lookup.
src/​api/​openapi.yml Defines image-mount API fields.
src/​api/​impls/​volumes.rs Supports read-only attachment and filtered listings.
src/​api/​impls/​sandbox.rs Integrates creation, restoration, and replacement checks.
src/​api/​impls/​mod.rs Registers image-mount handling.
src/​api/​impls/​image_mounts.rs Resolves images and creates scoped volumes.
src/​api/​impls/​image_build/​worker.rs Adapts worker requests.
src/​api/​impls/​image_build/​tests.rs Updates worker fixtures.
src/​api/​generated/​src/​models.rs Regenerates API models.
docs/​src/​getting-started/​aenv-cli/​sandboxes.md Documents CLI flags.
docs/​src/​concepts/​sandboxes/​starting.md Explains mount behavior and limitations.
crates/​aenv/​src/​commands/​start.rs Parses and validates mount flags.
crates/​aenv/​src/​client/​sandboxes.rs Serializes image-mount requests.

Review effort: Balanced

Comment thread src/api/impls/image_mounts.rs Outdated
.create_from_snapshot(
format!("image-mount-{}", Uuid::now_v7().simple()),
VolumeMode::Exclusive,
mount.size_mb.unwrap_or(DEFAULT_VOLUME_SIZE_MB),
Comment thread src/api/impls/image_mounts.rs Outdated
{
return Ok(published.layers);
}
let resolved = resolver.resolve(image).await.map_err(resolve_error)?;
Comment thread src/orchestrator/service.rs Outdated
Comment on lines +1244 to +1246
// Image mount volumes are deleted with the sandbox, so they are not captured.
let (volume_ids, scoped_volume_ids) = metadata
.map(|metadata| metadata.mounted_volume_ids())
@yingdi-shan

Copy link
Copy Markdown
Collaborator

I think we should set the default as writable rather than read-only.

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 8 issue(s) in this PR.

  • ✅ Successfully posted inline: 7 comment(s)
  • ❌ Failed to post inline: 1 comment(s)

bug · high

📄 src/sandbox/firecracker/sandbox.rs (L772-L773)

⚠️ GitHub could not post this as an inline comment: Lines 772-773 could not be resolved (outside PR diff hunks)

initial_guest_drive_mounts is passed to envd for mounting after boot, but this helper currently returns every launch-time drive, including writable reserved-slot volumes that are intentionally left in agentenv_drives for init to mount. Those writable volumes will therefore be mounted once by init and again here, which can fail or create duplicate mount state. Filter this list to mounts_after_boot(drive) (while tracking launch-time indices separately for sizing/attachment decisions).

💡 Suggested Change

Before:

        let initial_guest_drive_mounts =
            Self::initial_guest_drive_mounts_for_snapshot_launch(&snapshot_config, launch_config);

After:

        let initial_guest_drive_mounts =
            Self::initial_guest_drive_mounts_for_snapshot_launch(&snapshot_config, launch_config)
                .into_iter()
                .filter(|(_, drive)| mounts_after_boot(drive))
                .collect();


fn normalize_mount_path(value: &str) -> Result<String> {
if !value.starts_with('/') || value == "/" {
anyhow::bail!("volume mount path must be an absolute guest path other than /: {value}");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style · low
This diagnostic is emitted for image mounts too, so it incorrectly calls the offending path a "volume mount path". That makes CLI errors misleading when users pass --image-mount or --image-mount-rw; use a generic mount-path message (or pass the mount kind into the validator).

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +79 to +86
created.insert(
path,
volume.id,
ImageMount {
image: image.to_owned(),
read_only: mount.read_only.unwrap_or(true),
},
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The requested size_mb is used to create the volume but is not retained in ImageMount. Consequently, image_mounts_model cannot report it and any API/lifecycle path that reconstructs an image mount from this metadata loses the requested writable-view size (and defaults it on recreation). Preserve the size in the internal mount metadata, or derive and serialize it from the associated volume consistently.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +147 to +151
async fn delete_volumes(manager: &VolumeManager, volume_ids: &[String]) {
for volume_id in volume_ids {
let _ = manager.delete(volume_id).await;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
This rollback path discards deletion errors. If catalog deletion fails (for example due to a transient repository/storage error), the newly created sandbox-scoped volume remains in the catalog and its backing data is orphaned, while the request still returns the original failure. At minimum, surface/log the cleanup failure with the volume ID, and provide a retry/GC path for volumes that could not be rolled back.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +157 to +165
mounts
.iter()
.map(|(path, mount)| models::ImageMount {
image: mount.image.clone(),
mount_path: path.clone(),
read_only: Some(mount.read_only),
size_mb: None,
})
.collect()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
HashMap iteration makes the imageMounts array order nondeterministic across otherwise identical responses. Sort the paths (as the volume resolver does) before collecting so clients, snapshot tests, and serialized output remain stable.

Comment thread src/api/impls/sandbox.rs Outdated
Comment on lines +169 to +177
let mut all_mounts = mounts.cloned().unwrap_or_default();
for (path, volume_id) in &image_mounts.volumes {
if all_mounts.insert(path.clone(), volume_id.clone()).is_some() {
return Err(ApiImpl::error(
400,
format!("{path} is mounted as both a volume and an image"),
));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
The duplicate check here compares the image mount's already-normalized key with the explicit volume mount keys as supplied by the client. Explicit keys are only normalized later in resolve_volume_mounts, where the normalized map can overwrite an earlier entry (and both drives have already been prepared). Thus equivalent paths such as /mnt/models/ and /mnt/models, or overlapping paths such as /mnt and /mnt/models, can bypass this check and leave two attached drives while metadata contains only one path. Normalize all mount paths and reject collisions/overlaps before combining the maps.

Suggestion:

Suggested change
let mut all_mounts = mounts.cloned().unwrap_or_default();
for (path, volume_id) in &image_mounts.volumes {
if all_mounts.insert(path.clone(), volume_id.clone()).is_some() {
return Err(ApiImpl::error(
400,
format!("{path} is mounted as both a volume and an image"),
));
}
}
let mut all_mounts = normalize_volume_mounts(mounts)?;
for (path, volume_id) in &image_mounts.volumes {
if all_mounts.keys().any(|existing| {
Path::new(existing).starts_with(path) || Path::new(path).starts_with(existing)
}) {
return Err(ApiImpl::error(
400,
format!("{path} overlaps another volume or image mount"),
));
}
all_mounts.insert(path.clone(), volume_id.clone());
}

Comment thread src/api/impls/volumes.rs
Comment on lines 199 to 209
Ok(page) => Ok(
VolumesGetResponse::Status200_SuccessfullyListedTeamVolumes {
body: page.records.into_iter().map(Into::into).collect(),
body: page
.records
.into_iter()
.filter(|record| !record.sandbox_scoped)
.map(Into::into)
.collect(),
x_next_token: page.next_token,
},
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Filtering sandbox_scoped records after list_page makes hidden records consume the requested page size and continuation cursor. If a page contains only image-mount volumes, this returns an empty page with a cursor already advanced past those records; if hidden records precede visible ones, clients can receive short pages and pagination can skip visible volumes. The catalog query needs to exclude scoped records before applying the limit/cursor, or the API must keep fetching pages until it has filled the requested visible page and return a cursor based on the last scanned record.

Comment thread src/api/impls/volumes.rs Outdated
Comment on lines +201 to +206
body: page
.records
.into_iter()
.filter(|record| !record.sandbox_scoped)
.map(Into::into)
.collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
Filtering scoped records after list_page has already applied the limit and cursor can permanently omit visible volumes. For example, if a page contains only scoped records followed by persistent records, next_token is based on the end of that page and the persistent records are skipped; similarly, a full page of scoped records may return an empty result with no usable continuation. Apply the visibility filter while constructing the paginated catalog result (and derive the cursor from returned visible records), or fetch/advance pages until the requested visible page is filled.

Comment thread src/orchestrator/service.rs Outdated
Comment on lines +2986 to +2993
async fn delete_scoped_volumes(manager: &VolumeManager, volume_ids: &[String]) {
for volume_id in volume_ids {
match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => warn!(%volume_id, %error, "failed to delete image mount volume"),
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
A transient delete failure is only logged and then ignored, while delete_sandbox_impl proceeds to remove the sandbox metadata (and terminal cleanup has no later metadata-driven retry). This can permanently orphan a sandbox-scoped volume after a repository/filesystem failure. Propagate a non-NotFound error from this helper so sandbox deletion remains retryable, or persist a cleanup task/record that will retry the volume deletion.

Comment thread src/orchestrator/service.rs Outdated
Comment on lines +2987 to +2992
for volume_id in volume_ids {
match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => warn!(%volume_id, %error, "failed to delete image mount volume"),
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
This deletes based solely on the metadata path classification (image_mounts contains the mount path), without verifying that the volume record is actually sandbox_scoped. A stale/corrupt record, or a metadata update that pairs an image path with an existing persistent volume ID, can therefore permanently delete a user-managed volume during sandbox deletion. Fetch the record and only delete when sandbox_scoped is true (and otherwise report/retain the volume).

Suggestion:

Suggested change
for volume_id in volume_ids {
match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => warn!(%volume_id, %error, "failed to delete image mount volume"),
}
}
for volume_id in volume_ids {
match manager.get(volume_id).await {
Ok(record) if record.sandbox_scoped => match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => warn!(%volume_id, %error, "failed to delete image mount volume"),
},
Ok(_) => warn!(%volume_id, "refusing to delete non-scoped image mount volume"),
Err(VolumeError::NotFound(_)) => {}
Err(error) => warn!(%volume_id, %error, "failed to inspect image mount volume"),
}
}

Comment thread src/orchestrator/store/metadata.rs Outdated
Comment on lines +116 to +119
let (scoped, persistent): (Vec<_>, Vec<_>) = self
.volume_mounts
.iter()
.partition(|(path, _)| self.image_mounts.contains_key(*path));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
This assumes path membership is sufficient to identify the lifecycle of a volume. If persisted metadata is incomplete (for example, an image_mounts entry has no corresponding volume_mounts entry), the referenced scoped volume is never returned and cannot be released/deleted; if the path and ID are mismatched, a persistent volume is classified as scoped. Cleanup should validate the volume record's sandbox_scoped flag and handle inconsistent metadata explicitly rather than relying only on the two maps.

@Primary33

Copy link
Copy Markdown
Contributor Author

Updated in 1187974. Image mounts now default to writable in both the API and CLI.

Comment thread crates/aenv/src/client/sandboxes.rs Outdated
template_id: &str,
timeout: Option<u32>,
volume_mounts: Option<HashMap<String, String>>,
image_mounts: Option<Vec<ImageMount>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
This adds a required parameter to the public Client::create_sandbox/create_cold_sandbox methods, so any downstream users of the aenv client crate now fail to compile even when they do not use image mounts. Please preserve source compatibility (for example with a separate image-mount-aware method or an options/request type) or account for this as a breaking API change.

Comment on lines +158 to +159
let mount_path = normalize_mount_path(mount_path)
.with_context(|| format!("invalid image mount: {value}"))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
For invalid image mount paths, normalize_mount_path still emits diagnostics such as volume mount path must be... and volume mount path conflicts.... Because this function is now used by the image-mount flags, these messages identify the wrong input type and make CLI errors misleading; pass a mount-kind-specific label into normalization or map the errors to image-mount wording here.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +55 to +57
for mount in requested {
let path = normalize_mount_path(PathBuf::from(mount.mount_path.trim()))
.map_err(|error| ApiImpl::error(400, error.to_string()))?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
If this future is cancelled after one or more create_from_snapshot calls complete (for example, while resolving the next image), the Err cleanup below is never reached because cancellation drops the future. The created IDs are only held in this stack frame, so those sandbox-scoped volumes become orphaned. Use a drop/cancellation guard or an operation that owns and rolls back the partial set on cancellation; the same issue applies to snapshot restoration below.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +157 to +159
mounts
.iter()
.map(|(path, mount)| models::ImageMount {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
Iterating this HashMap directly makes the order of imageMounts vary between responses for the same sandbox. The existing volume-mount response is also map-backed, but this new field has no ordering guarantee or test; sort by guest path before collecting if clients/tests rely on stable API output.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +157 to +165
mounts
.iter()
.map(|(path, mount)| models::ImageMount {
image: mount.image.clone(),
mount_path: path.clone(),
read_only: Some(mount.read_only),
size_mb: None,
})
.collect()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The requested sizeMB is discarded when the sandbox metadata is built: ImageMount only retains image and read_only, and this response always emits size_mb: None. Consequently, a client that GETs a sandbox and reuses its imageMounts loses any custom mount size and silently recreates it at the default 64 GiB. Preserve the size in the internal metadata (and snapshot/fork metadata as appropriate) or populate this field from the mounted volume record.

Suggestion:

Suggested change
mounts
.iter()
.map(|(path, mount)| models::ImageMount {
image: mount.image.clone(),
mount_path: path.clone(),
read_only: Some(mount.read_only),
size_mb: None,
})
.collect()
mounts
.iter()
.map(|(path, mount)| models::ImageMount {
image: mount.image.clone(),
mount_path: path.clone(),
read_only: Some(mount.read_only),
size_mb: Some(mount.size_mb),
})
.collect()

Comment thread src/api/impls/sandbox.rs Outdated
mounts.insert(mount_path.to_string_lossy().into_owned(), child.id);
let mount_path = mount_path.to_string_lossy().into_owned();
match &volume_snapshot.image_mount {
Some(mount) => image_mounts.insert(mount_path, child.id, mount.clone()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
This insertion silently overwrites an earlier normalized path from the snapshot. The earlier child volume has already been created but is no longer present in either returned map, so it is neither cleaned up on later failure nor represented in the sandbox. Reject duplicate/overlapping normalized snapshot paths before creating the child (or clean up the overwritten ID).

Comment thread src/api/impls/volumes.rs Outdated
Comment on lines +201 to +206
body: page
.records
.into_iter()
.filter(|record| !record.sandbox_scoped)
.map(Into::into)
.collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
Filtering after fetching a backend page breaks the continuation contract: if this page contains only sandbox-scoped records, the response is empty while x_next_token advances past them. Clients that stop on an empty page (and clients expecting a full page) can miss persistent volumes later in the listing. Apply the scope filter before pagination, or fetch subsequent backend pages until the response is filled/exhausted and return the corresponding token.

Comment thread src/orchestrator/service.rs Outdated
Comment on lines +1244 to +1245
// Image mount volumes are deleted with the sandbox, so they are not captured.
let (volume_ids, scoped_volume_ids) = metadata

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
This excludes image-mount volumes from the orchestrator's freeze/snapshot/publish step even when an image mount is writable (read_only == false). The API later serializes their snapshot_volume_state, but without freezing/restacking/publishing the current upper layer, the committed snapshot can contain only the original image layers and lose writes made to the image mount on capture. Writable scoped mounts need to participate in capture (while still being deleted after capture), or must be explicitly rejected as non-snapshotable.

Comment thread src/orchestrator/service.rs Outdated
Comment on lines +2986 to +2993
async fn delete_scoped_volumes(manager: &VolumeManager, volume_ids: &[String]) {
for volume_id in volume_ids {
match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => warn!(%volume_id, %error, "failed to delete image mount volume"),
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Deletion errors are only logged here, after which delete_sandbox_impl proceeds to remove_deleted_sandbox. A transient catalog/storage failure therefore leaves the sandbox-scoped volume orphaned with no sandbox metadata left for the normal delete retry path to discover. Propagate a non-NotFound error (or persist a retryable cleanup record) so sandbox deletion remains incomplete and retryable until these volumes are removed.

Suggestion:

Suggested change
async fn delete_scoped_volumes(manager: &VolumeManager, volume_ids: &[String]) {
for volume_id in volume_ids {
match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => warn!(%volume_id, %error, "failed to delete image mount volume"),
}
}
}
async fn delete_scoped_volumes(manager: &VolumeManager, volume_ids: &[String]) -> Result<()> {
for volume_id in volume_ids {
match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => return Err(error.into()),
}
}
Ok(())
}

Comment thread src/orchestrator/store/metadata.rs Outdated
Comment on lines +116 to +119
let (scoped, persistent): (Vec<_>, Vec<_>) = self
.volume_mounts
.iter()
.partition(|(path, _)| self.image_mounts.contains_key(*path));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
This classifies a volume as deletable solely from the mount path and never verifies that the referenced VolumeRecord is actually sandbox_scoped. If a persisted/legacy record has an image-mount entry pointing at a persistent volume (or the same volume ID is also mounted at a normal path), terminal cleanup will release it and delete_scoped_volumes will delete user data. The cleanup classification should be based on the volume's scoped ownership/invariant, and inconsistent path/ID mappings should be rejected rather than treated as safe to delete.

Suggestion:

Suggested change
let (scoped, persistent): (Vec<_>, Vec<_>) = self
.volume_mounts
.iter()
.partition(|(path, _)| self.image_mounts.contains_key(*path));
// Classify using the volume record's sandbox-scoped flag, after validating
// that image mount paths and volume IDs form a one-to-one mapping.

Comment thread crates/aenv/src/client/sandboxes.rs Outdated
#[serde(skip_serializing_if = "Option::is_none", rename = "volumeMounts")]
pub volume_mounts: Option<Vec<SandboxVolumeMount>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "imageMounts")]
pub image_mounts: Option<Vec<ImageMount>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
NewSandbox and NewColdSandbox are public structs with public fields, so adding a required field makes existing downstream struct literals fail to compile, independently of the Client method signature change. Consider adding the field with a builder/options API or otherwise preserving construction compatibility for this public request type.

Comment on lines 115 to 117
volume_mounts: Option<HashMap<String, String>>,
image_mounts: Option<Vec<ImageMount>>,
) -> Result<Sandbox> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
This is a public client API, so adding a required positional parameter is a source-incompatible change for any downstream caller of Client::create_sandbox (and likewise for create_cold_sandbox). Consider preserving the existing methods and adding *_with_image_mounts/an options struct, or otherwise provide a compatibility path for clients that do not use image mounts.

Suggestion:

Suggested change
volume_mounts: Option<HashMap<String, String>>,
image_mounts: Option<Vec<ImageMount>>,
) -> Result<Sandbox> {
volume_mounts: Option<HashMap<String, String>>,
) -> Result<Sandbox> {

Comment on lines +158 to +159
let mount_path = normalize_mount_path(mount_path)
.with_context(|| format!("invalid image mount: {value}"))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
For invalid image-mount paths, normalize_mount_path emits diagnostics such as “volume mount path must...” and “volume mount path contains...”. This makes the new CLI flag report the wrong resource type; pass an image-specific path-validation error (or parameterize the helper) so users can distinguish image-mount failures from --volume failures.

Suggestion:

Suggested change
let mount_path = normalize_mount_path(mount_path)
.with_context(|| format!("invalid image mount: {value}"))?;
let mount_path = normalize_mount_path(mount_path)
.with_context(|| format!("invalid image mount path in: {value}"))?;

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +43 to +46
let result = create_image_mount_volumes_inner(api, requested, &mut created).await;
if result.is_err() {
delete_volumes(&api.volume_manager, &created.volume_ids()).await;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
This cleanup runs only when the inner future returns Err. If the request is cancelled while a later image is being resolved/created, the future is dropped with already-created volume IDs still in created, so no deletion runs and sandbox-scoped volumes can be orphaned. Use a cancellation-safe cleanup guard (and apply the same pattern to restore_snapshot_volume_mounts, whose local volume_ids/image_mounts are also cleaned only on explicit error paths).

Suggestion:

Suggested change
let result = create_image_mount_volumes_inner(api, requested, &mut created).await;
if result.is_err() {
delete_volumes(&api.volume_manager, &created.volume_ids()).await;
}
let result = create_image_mount_volumes_inner(api, requested, &mut created).await;
if result.is_err() {
delete_volumes(&api.volume_manager, &created.volume_ids()).await;
}

Comment thread src/api/impls/sandbox.rs Outdated
Comment on lines +181 to +186
let scoped_volume_ids = image_mounts.volume_ids();
let result = prepare_volume_mounts_inner(api, mounts, image_mounts).await;
if result.is_err() {
cleanup_volume_ids(&api.volume_manager, &scoped_volume_ids).await;
}
result

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The scoped IDs are cleaned only when prepare_volume_mounts_inner returns an error. If this future is cancelled while reserving/resolving drives, the already-created image-mount volumes are dropped with no cleanup guard and remain orphaned. This should use cancellation-safe ownership/cleanup, not only an is_err() branch.

Suggestion:

Suggested change
let scoped_volume_ids = image_mounts.volume_ids();
let result = prepare_volume_mounts_inner(api, mounts, image_mounts).await;
if result.is_err() {
cleanup_volume_ids(&api.volume_manager, &scoped_volume_ids).await;
}
result
let scoped_volume_ids = image_mounts.volume_ids();
let result = prepare_volume_mounts_inner(api, mounts, image_mounts).await;
if result.is_err() {
cleanup_volume_ids(&api.volume_manager, &scoped_volume_ids).await;
}
result

Comment thread src/api/impls/volumes.rs Outdated
Comment on lines 201 to 207
body: page
.records
.into_iter()
.filter(|record| !record.sandbox_scoped)
.map(Into::into)
.collect(),
x_next_token: page.next_token,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Filtering after list_page has already applied the limit makes pagination depend on hidden records. A page can contain only sandbox-scoped volumes and return an empty list with a non-empty cursor, and callers that treat an empty page as the end can stop before seeing later team volumes. Fetch/filter until the requested number of visible records is filled, or move the visibility predicate into the catalog query while computing the cursor from visible records.

Suggestion:

Suggested change
body: page
.records
.into_iter()
.filter(|record| !record.sandbox_scoped)
.map(Into::into)
.collect(),
x_next_token: page.next_token,
body: page
.records
.into_iter()
.filter(|record| !record.sandbox_scoped)
.map(Into::into)
.collect(),
x_next_token: page.next_token,

Comment thread src/orchestrator/service.rs Outdated
{
warn!(sandbox_id = %metadata.id, %error, "failed to release image mount volumes during terminal sandbox cleanup");
}
delete_scoped_volumes(manager, &scoped_volume_ids).await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · high
This permanently deletes every ID classified as scoped solely from the mount-path metadata. The cleanup has already cleared ownership, so a stale/corrupt record (or an ID aliased by a regular mount) can cause a persistent volume to be deleted as an image-mount volume. Before deletion, load each VolumeRecord and require sandbox_scoped (and ensure it is not referenced by any persistent mount), or otherwise reject/repair inconsistent metadata rather than trusting the path classification.

Suggestion:

Suggested change
delete_scoped_volumes(manager, &scoped_volume_ids).await;
delete_scoped_volumes(manager, &owner, &volume_ids, &scoped_volume_ids).await;

Comment thread src/orchestrator/service.rs Outdated
Comment on lines +2986 to +2993
async fn delete_scoped_volumes(manager: &VolumeManager, volume_ids: &[String]) {
for volume_id in volume_ids {
match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => warn!(%volume_id, %error, "failed to delete image mount volume"),
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Deletion errors are only logged and the caller still marks the sandbox Done/removes its metadata. A transient repository or filesystem failure therefore leaves the sandbox-scoped volume orphaned with no retry path, unlike the surrounding cleanup operations that return an error and retain deletion progress. Propagate a deletion failure (or persist a cleanup task) so a subsequent delete retries it before completing.

Suggestion:

Suggested change
async fn delete_scoped_volumes(manager: &VolumeManager, volume_ids: &[String]) {
for volume_id in volume_ids {
match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => warn!(%volume_id, %error, "failed to delete image mount volume"),
}
}
}
async fn delete_scoped_volumes(manager: &VolumeManager, volume_ids: &[String]) -> anyhow::Result<()> {
for volume_id in volume_ids {
match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => return Err(anyhow::anyhow!("failed to delete image mount volume {volume_id}: {error}")),
}
}
Ok(())
}

Comment thread src/orchestrator/store/metadata.rs Outdated
Comment on lines +115 to +119
pub(crate) fn mounted_volume_ids(&self) -> (Vec<String>, Vec<String>) {
let (scoped, persistent): (Vec<_>, Vec<_>) = self
.volume_mounts
.iter()
.partition(|(path, _)| self.image_mounts.contains_key(*path));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
This split only iterates volume_mounts, so an image_mounts entry without a matching backing-volume entry is silently omitted from cleanup and from snapshot handling. The API normally creates both maps together, but metadata is persisted/deserialized and can be partially written or migrated; cleanup should validate the one-to-one invariant (or return/report an inconsistency) instead of treating it as a valid empty set, so orphaned scoped volumes are not hidden permanently.

Suggestion:

Suggested change
pub(crate) fn mounted_volume_ids(&self) -> (Vec<String>, Vec<String>) {
let (scoped, persistent): (Vec<_>, Vec<_>) = self
.volume_mounts
.iter()
.partition(|(path, _)| self.image_mounts.contains_key(*path));
pub(crate) fn mounted_volume_ids(&self) -> Result<(Vec<String>, Vec<String>), MetadataError> {
if self.image_mounts.keys().any(|path| !self.volume_mounts.contains_key(path)) {
return Err(MetadataError::InconsistentMounts);
}
let (scoped, persistent): (Vec<_>, Vec<_>) = self
.volume_mounts
.iter()
.partition(|(path, _)| self.image_mounts.contains_key(*path));

Comment on lines +1380 to 1382
.filter(|(_, drive)| !mounts_after_boot(drive))
.map(|(i, drive)| {
let dev_letter = (b'c' + i as u8) as char;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
The index is taken before filtering, so a post-boot read-only volume in the middle leaves a device-letter hole. For example, [writable, read-only volume, writable] emits vdc for the first and vde for the second, while the drive list/device attachment remains contiguous (vdc, vdd); init will therefore mount the wrong device or fail. Filter first and then enumerate the remaining drives, while retaining the original index only for the post-boot mount list.

Suggestion:

Suggested change
.filter(|(_, drive)| !mounts_after_boot(drive))
.map(|(i, drive)| {
let dev_letter = (b'c' + i as u8) as char;
.iter()
.filter(|drive| !mounts_after_boot(drive))
.enumerate()
.map(|(i, drive)| {
let dev_letter = (b'c' + i as u8) as char;

Comment thread crates/aenv/src/client/sandboxes.rs Outdated
Comment on lines 116 to 117
image_mounts: Option<Vec<ImageMount>>,
) -> Result<Sandbox> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · high
These public client methods now require a new positional argument, so any downstream caller (and any command/test path outside this repository) that uses the existing API will fail to compile. Prefer preserving the existing methods and adding image-mount-aware variants, or make the new parameter optional via a request/options type, while updating internal callers.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +43 to +47
let result = create_image_mount_volumes_inner(api, requested, &mut created).await;
if result.is_err() {
delete_volumes(&api.volume_manager, &created.volume_ids()).await;
}
result.map(|()| created)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
This cleanup only runs when the future resolves to Err. If the request is cancelled while an image is being resolved or created (for example, after earlier mounts have already been created), the async function is dropped and created.volume_ids() is never deleted. The partially created sandbox-scoped volumes can therefore leak indefinitely because no sandbox owns them yet; use a cancellation-safe guard/cleanup mechanism around the in-progress creation.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +60 to +65
if created.mounts.contains_key(&path) {
return Err(ApiImpl::error(
400,
format!("duplicate image mount path: {path}"),
));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Only exact duplicates are rejected here. Two requested image mounts such as /mnt/data and /mnt/data/sub both pass, and prepare_volume_mounts_inner only checks exact key collisions before delegating to resolve_volume_mounts; although that resolver rejects overlaps among the combined map, the API's image paths are normalized and it does catch there actually, but this validation is not performed until after all image volumes have been created. A malformed overlapping request can therefore create all sandbox-scoped volumes and only then fail, relying on cleanup; validate ancestor/descendant overlap while processing requests so the request is rejected before unnecessary volume creation.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +195 to +197
mounts
.iter()
.map(|(path, mount)| models::ImageMount {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · low
Iterating a HashMap directly makes the order of imageMounts in list/detail responses nondeterministic. This produces unstable API payloads and can cause clients/tests that compare response arrays to observe spurious changes. Sort the paths before constructing the response vector (as mount resolution already does for volume mounts).

Comment thread src/api/impls/sandbox.rs Outdated
Comment on lines +181 to +183
let scoped_volume_ids = image_mounts.volume_ids();
let result = prepare_volume_mounts_inner(api, mounts, image_mounts).await;
if result.is_err() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
This cleanup also only runs after prepare_volume_mounts_inner resolves. If the request is cancelled while reserving/materializing these mounts, or while the subsequent orchestrator call is in flight, the already-created sandbox-scoped volumes are dropped from the current stack without being deleted. The normal error branches do not cover cancellation, so a cancellation-safe ownership guard is needed for the whole launch operation.

Comment thread src/orchestrator/service.rs Outdated
Comment on lines +2986 to +2993
async fn delete_scoped_volumes(manager: &VolumeManager, volume_ids: &[String]) {
for volume_id in volume_ids {
match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => warn!(%volume_id, %error, "failed to delete image mount volume"),
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Deletion failures are only logged, while the sandbox metadata is subsequently removed. Since the cleanup retry in delete_sandbox_impl only retries errors returned by the cleanup future, a transient repository/filesystem failure here leaves the sandbox-scoped volume orphaned with no durable retry state. Propagate the first deletion error (or record it in persistent cleanup state) so the existing deletion retry can run.

Comment thread src/orchestrator/store/metadata.rs Outdated
let ids = |mounts: Vec<(&String, &String)>| -> Vec<String> {
mounts.into_iter().map(|(_, id)| id.clone()).collect()
};
(ids(persistent), ids(scoped))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
This split assumes a volume ID is associated with only one mount path. The API only rejects duplicate paths, not reusing the same volume ID at both an image-mount path and a normal volume path, so a scoped ID can appear in both vectors; terminal cleanup will then delete it even though the persistent mount still references it. Classify by IDs (excluding any scoped ID also present in persistent mounts) or reject duplicate volume IDs when preparing mounts.

Comment on lines +923 to +924
// and init mounts fresh drives except read-only volumes. Only the
// remaining drives need a guest-side mount.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
initial_guest_drive_mounts_for_snapshot_launch currently returns every launch-time drive, including writable volumes. Once this list is non-empty, prepare_snapshot_backing_drives skips binding all launch drives before snapshot load, and start then asks envd to mount every listed drive. However init already mounts writable drives from agentenv_drives; this causes writable volumes to be mounted twice (and can fail with a busy mount or mask the init mount). Keep only mounts_after_boot drives in this list, while retaining the non-empty check so writable drives continue to be handled by init.

Suggestion:

Suggested change
// and init mounts fresh drives except read-only volumes. Only the
// remaining drives need a guest-side mount.
// and init mounts fresh drives except read-only volumes. Only the
// remaining drives need a guest-side mount.

Comment on lines +1380 to 1382
.filter(|(_, drive)| !mounts_after_boot(drive))
.map(|(i, drive)| {
let dev_letter = (b'c' + i as u8) as char;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
The device letter intentionally uses the original extra_drives index, but the boot argument now omits read-only volume entries. This is only safe if init's parser treats the vd* names as explicit devices; please verify that contract (and add a test with an omitted middle drive). If the parser instead assigns entries sequentially, a later drive such as vde will be mounted as vdd, while the post-boot path uses the original slot and mounts the wrong device.

Comment thread storage/ublk-daemon/src/client.rs Outdated
Comment on lines +497 to +501
match self
.call(request, self.inner.runtime_device_timeout)
.await?
{
DaemonResponse::OverlaybdVirtualSize { virtual_size } => Ok(virtual_size),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
This new RPC is sent unconditionally, while the protocol has no version negotiation or compatibility fallback. During a rolling upgrade, a newer API process can send this variant to an older daemon, which will fail to deserialize/handle the request and make every image-mount creation fail. Add a daemon capability/version gate (or an explicitly supported fallback) before using this request.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +42 to +47
let mut created = CreatedImageMounts::default();
let result = create_image_mount_volumes_inner(api, requested, &mut created).await;
if result.is_err() {
delete_volumes(&api.volume_manager, &created.volume_ids()).await;
}
result.map(|()| created)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
Cleanup only runs when the inner future returns Err; if the request is cancelled at any .await after one or more volumes have been created, this future is dropped and the created sandbox-scoped volumes are leaked. The same cancellation window exists after prepare_volume_mounts returns in the sandbox handlers, before the later success/error branches. Use an owned cleanup guard (disarmed only after ownership is transferred to the sandbox) or otherwise make cancellation clean up all created IDs.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +60 to +65
if created.mounts.contains_key(&path) {
return Err(ApiImpl::error(
400,
format!("duplicate image mount path: {path}"),
));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
The API only rejects an exact duplicate here, while the CLI's parser rejects ancestor/descendant overlaps (for example /mnt and /mnt/models). Direct API callers can therefore create both mounts; later attachment treats them as separate drives, so one mount can shadow the other and expose unexpected filesystem contents. Validate mount_paths_overlap against all requested image paths and the requested volume mounts at the API boundary, rather than relying on CLI-only validation.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +60 to +65
if created.mounts.contains_key(&path) {
return Err(ApiImpl::error(
400,
format!("duplicate image mount path: {path}"),
));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · high
This validation only detects exact duplicates among image mounts. The later merge with ordinary volume_mounts rejects only an exact key collision as well, so a request such as an image at /mnt/models plus a volume at /mnt (or the reverse) is accepted even though these paths overlap and one mount can shadow the other. Validate image paths against all requested volume paths (including ancestor/descendant relationships) before creating these volumes, or apply the shared overlap validator.

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +69 to +80
let volume = api
.volume_manager
.create_from_snapshot(
format!("image-mount-{}", Uuid::now_v7().simple()),
VolumeMode::Exclusive,
size_mb,
layers,
true,
)
.await
.map_err(|error| volume_error_response(error).1)?;
created.insert(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · high
If this future is cancelled after create_from_snapshot has persisted the volume but before it returns, created.insert is never reached and the request-level rollback cannot discover the new ID. This can leave an unreserved sandbox-scoped volume orphaned permanently. Please make volume creation/registration cancellation-safe (for example, have the creation helper own a drop/cancellation guard or provide an atomic create-and-register/rollback mechanism).

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +195 to +202
mounts
.iter()
.map(|(path, mount)| models::ImageMount {
image: mount.image.clone(),
mount_path: path.clone(),
read_only: Some(mount.read_only),
})
.collect()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
This serializes a HashMap directly, so the order of imageMounts in sandbox responses varies between processes. Other mount APIs sort paths before returning them; sorting these entries by mount_path would make responses deterministic for clients and snapshot comparisons.

Suggestion:

Suggested change
mounts
.iter()
.map(|(path, mount)| models::ImageMount {
image: mount.image.clone(),
mount_path: path.clone(),
read_only: Some(mount.read_only),
})
.collect()
let mut mounts: Vec<_> = mounts
.iter()
.map(|(path, mount)| models::ImageMount {
image: mount.image.clone(),
mount_path: path.clone(),
read_only: Some(mount.read_only),
})
.collect();
mounts.sort_unstable_by(|left, right| left.mount_path.cmp(&right.mount_path));
mounts

Comment thread src/api/impls/volumes.rs Outdated
Comment on lines +123 to +124
// Only writable drives produce new layers to keep in the volume backing.
let snapshot_output_dir = (!read_only).then(|| manager.data_dir(&volume.id));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
This changes the previous VolumeMode::Exclusive condition to all writable volumes, including VolumeMode::Shared. Shared volumes are intentionally writable but do not have exclusive snapshot semantics; assigning them a snapshot output directory makes capture/fork logic persist their writes as backing layers and changes the existing shared-volume behavior. Please retain the mode check (e.g. volume.mode == VolumeMode::Exclusive && !read_only) unless shared-volume snapshotting is explicitly intended and handled throughout the orchestrator.

Comment thread src/api/impls/volumes.rs Outdated
Comment on lines 201 to 207
body: page
.records
.into_iter()
.filter(|record| !record.sandbox_scoped)
.map(Into::into)
.collect(),
x_next_token: page.next_token,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Filtering after list_page paginates the complete volume set makes the cursor describe the last unfiltered record, while the response contains only visible records. If a page consists entirely of sandbox-scoped volumes, this returns an empty page with a cursor; more generally, visible records can be skipped or clients can see short/unstable pages as hidden records are traversed. Apply the predicate before pagination (or make the repository query exclude scoped records) so the cursor and page size are based on the user-visible collection.

Comment thread src/orchestrator/service.rs Outdated
Comment on lines +2986 to +2993
async fn delete_scoped_volumes(manager: &VolumeManager, volume_ids: &[String]) {
for volume_id in volume_ids {
match manager.delete(volume_id).await {
Ok(()) | Err(VolumeError::NotFound(_)) => {}
Err(error) => warn!(%volume_id, %error, "failed to delete image mount volume"),
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Deletion errors are only logged after the sandbox metadata is removed, so a transient repository/storage failure leaves the scoped volume record and backing behind with no sandbox metadata to trigger another cleanup attempt. This can orphan storage indefinitely. Please propagate the failure into the delete progress (or persist a cleanup job/retry marker) so cleanup is retried rather than treating every error as successful deletion.

Comment thread src/orchestrator/store/metadata.rs Outdated
Comment on lines +116 to +122
let (scoped, persistent): (Vec<_>, Vec<_>) = self
.volume_mounts
.iter()
.partition(|(path, _)| self.image_mounts.contains_key(*path));
let ids = |mounts: Vec<(&String, &String)>| -> Vec<String> {
mounts.into_iter().map(|(_, id)| id.clone()).collect()
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
This classification does not deduplicate IDs or protect against the same volume being mounted at both an image path and a normal volume path. In that case the ID is returned in both lists; terminal cleanup first releases it as a persistent volume and then deletes it as scoped, even though the normal mount still references it. Please reject duplicate volume IDs when constructing/validating mount metadata, or classify/destroy only volumes whose ownership and sandbox_scoped record are verified.

Comment thread src/orchestrator/store/metadata.rs Outdated
Comment on lines +116 to +123
let (scoped, persistent): (Vec<_>, Vec<_>) = self
.volume_mounts
.iter()
.partition(|(path, _)| self.image_mounts.contains_key(*path));
let ids = |mounts: Vec<(&String, &String)>| -> Vec<String> {
mounts.into_iter().map(|(_, id)| id.clone()).collect()
};
(ids(persistent), ids(scoped))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
This relies on an unvalidated invariant that every image_mounts path has exactly one matching volume_mounts entry. An inconsistent persisted record (or a legacy/partial record) causes an image mount with no matching volume to be omitted from cleanup, while a volume at a path missing from image_mounts is treated as persistent and retained. Validate/normalize this invariant when persisting/restoring metadata, or make cleanup use the volume record's sandbox_scoped flag and handle orphaned image-mount entries explicitly.

@yingdi-shan
yingdi-shan self-requested a review October 23, 2026 14:04

@yingdi-shan yingdi-shan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth fixing

  1. Deleting a sandbox also freezes its writable image mounts (Copilot r4321427516). If the sandbox also has a persistent volume, delete calls freeze_and_snapshot_volumes(). That function freezes and seals every writable drive with a snapshot output directory, and writable image mounts have one. So the delete path freezes and seals drives it is about to throw away. If that fails, the error is treated as recoverable and the sandbox goes back to Running. In other words, a disposable mount can block the delete.

  2. Deleting a sandbox ignores failures to delete its image-mount volumes (raised 5 times, e.g. r4321531847, r4344073791). delete_scoped_volumes only logs the error, and then the sandbox metadata is removed, so nothing ever retries. This is worse than it looks: these volumes are hidden from GET /volumes, so users can't see or remove the leftovers either. Two related comments end the same way: the request-rollback path that ignores deletes with let _ (r4321531810), and trying to delete after replace_owner_for fails, where delete rejects a reserved volume (r4332554193).

  3. A cancelled request leaves volumes behind (raised 7 times, e.g. r4344073731, r4344073747). Cleanup only runs when the code returns Err. If the client disconnects and the request is dropped, volumes already created are never deleted. The existing snapshot-restore code has the same pattern, but again hidden volumes can't be cleaned up by users. A single fix covers both: a background sweep that deletes unreserved volumes marked sandbox_scoped. That's simpler than adding cancellation guards everywhere.

  4. Standard OCI images get converted before being rejected (Copilot r4321427485). When convert_standard_oci is on, resolve() downloads and converts the whole image (resolver.rs:607). Only afterwards does the !layer.file.is_empty() check return 400. The user gets the right error, but only after a lot of wasted conversion work.

  5. Check that a volume is sandbox_scoped before deleting it (r4321531851, r4323459648, r4344073795 and others). The code decides what to delete only from the mount path. I don't see a way to trigger this today, because the server always writes both maps together. But the delete can't be undone and the check is a few lines, so it's cheap protection against ever deleting a user's volume.

Minor

  • imageMounts order in responses changes between calls, because they come straight out of a HashMap (raised 4 times). Easy to sort. The existing volume_mounts_model has the same problem.
  • Volume listing can return short or empty pages that still carry a next-page token (raised 6 times). The comments claiming volumes get skipped are wrong: the cursor moves past hidden records, so nothing is lost. The in-repo CLI keeps paging until there's no token, so it's fine. Only clients that stop at an empty page would miss volumes.
  • Overlapping mount paths are rejected only after the image volumes are created (r4332554156). The result is correct because of the rollback; it's just wasted work.
  • The CLI says "volume mount path …" for image-mount errors (raised 3 times). It's already prefixed with invalid image mount: …, so this is cosmetic.

@Primary33

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Addressed:

  1. Sandbox deletion now freezes and seals only persistent volumes, so writable image mounts can't block it.
    2/3. Added a periodic sweep that deletes orphaned image mount volumes left by failed deletes, cancelled requests, or crashes. It uses a 1-hour grace period so it can't race creates on other nodes.
  2. Image mounts now reject standard OCI images before any conversion.
  3. Sandbox cleanup only deletes volumes marked sandbox_scoped.

Minor: mount lists are sorted by path, and mount paths/counts are validated before any volume is created. I left pagination and the CLI wording as is.

Comment thread crates/aenv/src/client/sandboxes.rs Outdated
Comment on lines 60 to 64
#[serde(skip_serializing_if = "Option::is_none", rename = "imageMounts")]
pub image_mounts: Option<Vec<ImageMount>>,
}

#[derive(Deserialize)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
Please add a serialization test for NewColdSandbox with one image mount (including mountPath and readOnly). The current cold test only sets image_mounts: None, so a serde rename or omission in the --cold request can regress without being detected.

Suggestion:

Suggested change
#[serde(skip_serializing_if = "Option::is_none", rename = "imageMounts")]
pub image_mounts: Option<Vec<ImageMount>>,
}
#[derive(Deserialize)]
#[serde(skip_serializing_if = "Option::is_none", rename = "imageMounts")]
pub image_mounts: Option<Vec<ImageMount>>,
}
#[derive(Deserialize)]

Comment thread src/api/impls/image_mounts.rs Outdated
Comment on lines +183 to +188
crate::snapshot::store::store_volume_layers(
api.snapshot_manager.repository().as_ref(),
&resolved.overlaybd_config_path,
)
.await
.map_err(|error| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
store_volume_layers uploads local layers/blobs before the volume record exists, but this error path only maps the error and does not remove those newly stored blobs. If image_virtual_size_mb or create_from_snapshot fails afterward, the layer references are no longer reachable from any volume and repeated failed image-mount requests can leak repository storage. Please add rollback/ownership for the acquired layers (or defer/transactionally couple this operation to volume creation).

Comment thread src/api/impls/volumes.rs Outdated
Comment on lines 204 to 207
.filter(|record| !record.sandbox_scoped)
.map(Into::into)
.collect(),
x_next_token: page.next_token,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
Filtering after fetching a fixed-size page while returning that page's cursor can produce short or empty visible pages and skip user-visible volumes after the cursor. For example, a page containing only hidden scoped volumes advances x_next_token past them, and clients that stop on an empty page will never see later regular volumes. The handler should filter at the repository/query level or keep fetching until the requested visible page is filled and construct the cursor from the last examined record.

Comment thread src/volume/mod.rs Outdated
Comment on lines +43 to +44
let (seconds, nanos) = uuid.get_timestamp()?.to_unix();
Some(UNIX_EPOCH + Duration::new(seconds, nanos))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · high
Timestamp::to_unix() returns a signed seconds value (i64 in the uuid 1.x API), while Duration::new requires u64. This does not compile; convert/check the seconds value before constructing the duration (and handle pre-epoch timestamps explicitly).

Comment thread src/volume/mod.rs Outdated
Comment on lines +502 to +506
pub(crate) async fn sweep_orphaned_scoped_volumes(
&self,
now: SystemTime,
grace: Duration,
) -> Result<usize, VolumeError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
This cleanup method is only defined here and has no caller in the changed codebase. Consequently, failed/cancelled image-mount creates leave their sandbox-scoped volumes indefinitely, so the intended one-hour orphan policy is never applied. Schedule it from startup or a periodic maintenance task on nodes sharing the catalog.

Comment thread src/volume/mod.rs Outdated
Comment on lines +525 to +529
if let Some(owner) = pending_owner {
self.replace_owner_for(owner, None, std::slice::from_ref(&record.id))
.await?;
}
self.delete(&record.id).await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · high
This release-then-delete sequence is racy with finish_volume_reservation: if the sweep clears pending_owner first, the concurrent finish sees no matching owner and cannot bind the volume to the sandbox; the subsequent delete then succeeds and removes a volume that the launch is about to use. Conversely, if finish wins, delete is protected by the sandbox reservation. Make orphan deletion conditional on the pending owner in one repository transaction (or otherwise serialize the owner transition with deletion) so a pending reservation cannot be cleared and deleted while it is being committed.

Suggestion:

Suggested change
if let Some(owner) = pending_owner {
self.replace_owner_for(owner, None, std::slice::from_ref(&record.id))
.await?;
}
self.delete(&record.id).await
// Release and delete only if the pending owner is still present, atomically.
self.delete_if_owned_by(&record.id, pending_owner).await

@yingdi-shan

Copy link
Copy Markdown
Collaborator

Thanks for the work on this. Before we go further, I'd like to change the design.

Image mounts shouldn't be volumes

A volume is an independent resource that outlives its sandbox. An image mount is the opposite: it belongs to one sandbox and goes away with it. Because this PR makes each mount a catalog volume, it then has to undo most of what volumes do:

  • sandbox_scoped filtering in GET /volumes, in sandbox deletion, and in fork-child creation
  • pending-<uuidv7> owners, plus a periodic sweep with a 1-hour grace period for volumes left behind by failed, cancelled, or crashed requests
  • freeze and seal on delete limited to persistent volumes
  • read_only_paths passed through resolve_volume_mounts
  • image mounts counting against volume.max_volume_count and max_size_mb, which needs a new daemon RPC to read the size

None of this would be needed if the mount weren't a volume. The sweep is the clearest sign: it exists only because we create a durable record for something that has no life of its own.

ExtraDrive already does what an image mount needs

  • It carries mount_path, read_only, sub_path, and virtual_size. With virtual_size: None, the ublk daemon works out the image size itself.
  • On capture, snapshot_extra_drives seals every drive. With snapshot_output_dir: None, the sealed layer goes into the snapshot's own drives/<id> directory. The snapshot owns it, not a catalog record.
  • Drives that aren't volumes are committed to the manifest as attached drives. They come back on snapshot launch, fork, and pause/resume without extra code.
  • On delete, the drive's runtime state is removed with the sandbox's work directory.

On cold start, an image mount is already an attached drive.

No new API: reuse attachedDrives

AttachedDrive already has everything imageMounts adds: mountPath, readOnly, subPath, diskSizeMB, and an image source. Instead of a new imageMounts field and a new ImageMount schema, please add the existing attachedDrives field to POST /v2/sandboxes, keep its current semantics and defaults, and drop imageMounts from both endpoints. That gives users one way to attach an image on both cold and warm start, and the cold-start code path stays as it is. The CLI flags can stay; they just build attachedDrives entries and derive driveID from the mount path.

The one real gap: warm start

A snapshot launch can add drives only through the reserved volume slots. Today volume: true (set by with_volume_snapshot_output_dir) means two things at once:

  1. the drive sits in a reserved slot at launch, and
  2. the drive is backed by a catalog volume, so capture leaves it out of the manifest and the orchestrator rebinds it from the catalog.

Attached drives on warm start need (1) without (2).

Suggested shape

  1. Add attachedDrives to the v2 request. Reference the existing AttachedDrives schema in src/api/openapi.yml, regenerate, and remove imageMounts/ImageMount.
  2. Split the flag on ExtraDrive. For example, a small enum { Physical, LaunchSlot, Volume }. with_volume_snapshot_output_dir keeps producing Volume. Attached drives on a warm start use LaunchSlot with snapshot_output_dir: None.
  3. Resolve v2 attached drives with resolve_attached_drives. It is the same function cold start uses, so resolution, validation, and image handling match. Pass the resulting drives in CreateSandboxRequest::extra_drives alongside the volume drives, with attached drives before volume drives in the slot range. Also check their mount paths against the volume mounts.
  4. Commit LaunchSlot drives on capture. Treat them like physical drives: they become physical drives of the new snapshot. In FirecrackerSandbox::snapshot, physical_extra_drive_count then grows by the number of these drives and volume_drive_slots shrinks by the same number, so device indices still match the VM state. This bookkeeping is the part that needs real care and a dedicated test, covering capture → launch → capture again, plus fork.
  5. Snapshot launches keep the drives they captured. Captured attached drives are ordinary drives of the snapshot, as they already are for snapshots taken from cold-started sandboxes, so the volume-specific launch rules don't apply to them.
  6. Limit them by drive slots only, not by volume quotas.

What this removes

The imageMounts API field, the ImageMount schema and type, src/api/impls/image_mounts.rs, the imageMount snapshot markers, sandbox_scoped, pending_owner/uuid_v7_time, ORPHANED_SCOPED_VOLUME_GRACE and the sweep task, delete_scoped_volumes, the GET /volumes filter, mounted_volume_ids/persistent_volume_mount_paths, the freeze_and_snapshot_volumes(paths) change, read_only_paths, the sandbox_scoped parameter on create_from_snapshot, and probably the resolve_overlaybd_virtual_size RPC (only the max_size_mb check uses it). Rollback for failed requests goes away too, because nothing durable is created before the sandbox exists.

What remains is a small API change (one existing field on one more endpoint), the ExtraDrive flag split, the capture bookkeeping, and the CLI flags.

@Primary33 Primary33 changed the title feat: mount images into sandboxes feat: attach image drives to warm starts Oct 26, 2026
@Primary33

Copy link
Copy Markdown
Contributor Author

Thanks, reworked as suggested. Image mounts are now plain attached drives. POST /v2/sandboxes accepts attachedDrives and resolves them the same way cold starts do, and the CLI flags just build those entries. All the volume-based machinery is gone.

One difference from your sketch: after capture, a launch-time drive keeps its reserved slot instead of becoming a physical drive. The VM state records it under the slot's device ID (agentenv_volume_slot_N), so it has to come back in the same slot. physical_extra_drive_count now counts only drives with their own device IDs, and later launch-time drives take the slots after it. A new integration test covers capture → launch → fork → capture again.

Snapshots that already have volumes or attached drives reject explicit attachedDrives, matching #405.

Comment thread src/api/impls/sandbox.rs
Comment on lines +198 to +200
if let Some(drive) = drives.iter().find(|drive| {
path.starts_with(drive.mount_path()) || drive.mount_path().starts_with(&path)
}) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The prefix check treats unrelated paths such as /workspace/data2 as overlapping /workspace/data. This rejects valid volume mounts (and similarly can misclassify a drive path) because Path::starts_with here is being used on strings without enforcing a path-component boundary. Compare normalized Path components or check the parent/relative relationship instead.

Suggestion:

Suggested change
if let Some(drive) = drives.iter().find(|drive| {
path.starts_with(drive.mount_path()) || drive.mount_path().starts_with(&path)
}) {
if let Some(drive) = drives.iter().find(|drive| {
path == drive.mount_path()
|| path.starts_with(drive.mount_path()) && path.strip_prefix(drive.mount_path()).is_some_and(|suffix| suffix.starts_with(std::path::MAIN_SEPARATOR))
|| drive.mount_path().starts_with(&path)
}) {

Comment on lines +1853 to +1858
let new_indices = self
.initial_guest_drive_mounts
.iter()
.map(|(index, _)| *index)
.collect::<Vec<_>>();
let is_new_drive = |index: usize| new_indices.contains(&index);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
initial_guest_drive_mounts only contains drives that must be mounted after boot (currently fresh read-only reserved-slot drives and snapshot launch drives). A newly supplied writable volume is intentionally omitted because init mounts it, but it is still absent from the snapshot source state. Consequently this closure classifies that drive as Resume, causing preparation to use its requested virtual size as the known source size and to disable shrinking instead of using fresh preparation. Track launch-time/new drives independently of post-boot mount requirements, or derive the mode from the snapshot-versus-launch drive boundary.

Comment on lines +139 to +145
pub fn effective_physical_extra_drive_count(&self) -> usize {
if self.volume_drive_slots == 0 {
self.attached_drives.len()
} else {
self.physical_extra_drive_count
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
physical_extra_drive_count is #[serde(default)], so a manifest with reserved slots but no serialized count is interpreted as having zero physical drives. Since the manifest format version is unchanged, older or partially migrated manifests can contain volume_drive_slots > 0 without this newly relied-on field; their attached drives will be reclassified as launch-slot drives and restored through the wrong Firecracker slots. Add an explicit migration/legacy fallback (or reject such manifests) rather than treating a missing count as a valid zero.

Suggestion:

Suggested change
pub fn effective_physical_extra_drive_count(&self) -> usize {
if self.volume_drive_slots == 0 {
self.attached_drives.len()
} else {
self.physical_extra_drive_count
}
}
pub fn effective_physical_extra_drive_count(&self) -> usize {
if self.volume_drive_slots == 0 {
self.attached_drives.len()
} else {
self.physical_extra_drive_count
}
}

Comment on lines +148 to +154
pub fn free_drive_slots(&self) -> usize {
let slot_drives = self
.attached_drives
.len()
.saturating_sub(self.effective_physical_extra_drive_count());
self.volume_drive_slots.saturating_sub(slot_drives)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
This does not reject an inconsistent manifest where physical_extra_drive_count > attached_drives.len(). In that case slot_drives becomes zero and reports all reserved slots as free, while extra_drives() still uses the oversized count; a launch can then reach extra_drives.len() - launch_config.extra_drives.len() with an underflow and panic. Validate the count/order invariant before exposing slot capacity (or make the manifest validation fallible) instead of using saturating arithmetic to accept malformed state.

Suggestion:

Suggested change
pub fn free_drive_slots(&self) -> usize {
let slot_drives = self
.attached_drives
.len()
.saturating_sub(self.effective_physical_extra_drive_count());
self.volume_drive_slots.saturating_sub(slot_drives)
}
pub fn free_drive_slots(&self) -> usize {
debug_assert!(self.physical_extra_drive_count <= self.attached_drives.len());
let slot_drives = self
.attached_drives
.len()
.saturating_sub(self.effective_physical_extra_drive_count());
self.volume_drive_slots.saturating_sub(slot_drives)
}

Comment thread src/types/extra_drive.rs
Comment on lines +79 to +84
match kind {
ExtraDriveKind::Physical => Repr::Legacy(false),
ExtraDriveKind::LaunchSlot => Repr::Named(Named::LaunchSlot),
ExtraDriveKind::Volume => Repr::Legacy(true),
}
.serialize(serializer)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
LaunchSlot is serialized as the string "launch_slot" under the legacy volume key, whereas the compatibility contract for this field was a boolean. Any older binary or external config consumer that still deserializes volume: bool will reject these otherwise valid drive configs, so this is not backward-compatible in the downgrade direction. Consider a separate versioned/tagged field for the new kind, or explicitly prevent launch-slot values from crossing the legacy config boundary.

Suggestion:

Suggested change
match kind {
ExtraDriveKind::Physical => Repr::Legacy(false),
ExtraDriveKind::LaunchSlot => Repr::Named(Named::LaunchSlot),
ExtraDriveKind::Volume => Repr::Legacy(true),
}
.serialize(serializer)
match kind {
ExtraDriveKind::Physical => Repr::Legacy(false),
ExtraDriveKind::LaunchSlot => Repr::Legacy(false),
ExtraDriveKind::Volume => Repr::Legacy(true),
}
.serialize(serializer)

Comment thread src/types/extra_drive.rs
Comment on lines +212 to 218
pub fn with_kind(&self, kind: ExtraDriveKind) -> Self {
let mut drive = self.clone();
match &mut drive {
Self::Overlaybd { kind: current, .. } => *current = kind,
}
drive
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
This public setter allows callers to turn any drive into Volume or LaunchSlot without establishing the invariants those kinds require (for example, a volume snapshot output/volume binding or a reserved-slot position). The resulting value is accepted by downstream code based only on the enum and can fail later or be captured with unintended semantics. Prefer constructors/validated transitions for each kind, or restrict this helper to crate/test scope and validate kind-specific metadata at the boundary.

Comment on lines +529 to +533
.run_command(
"sh",
&["-lc", &format!("echo {file} > /mnt/data/{file} && sync")],
)
.await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · low
This helper interpolates file into a shell program and a redirection path without quoting or validation. Any future caller passing a value containing shell metacharacters (or a path such as ../...) could execute commands or write outside /mnt/data; use a fixed command with a separately validated filename, or pass data/path arguments without invoking sh -c.

Suggestion:

Suggested change
.run_command(
"sh",
&["-lc", &format!("echo {file} > /mnt/data/{file} && sync")],
)
.await?;
anyhow::ensure!(
file
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
"invalid guest filename: {file}"
);
let output = sandbox
.run_command("sh", &["-c", "printf '%s\\n' "$1" > "/mnt/data/$1" && sync", "--", file])
.await?;

Comment thread docs/src/concepts/volumes/index.md Outdated
Comment on lines +4 to +5
> drive supplied only when starting a sandbox, the `attachedDrives` feature on
> `POST /sandboxes-cold` and `POST /v2/sandboxes` is more extensively tested. See the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not need this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted, thanks.

Accept attachedDrives on POST /v2/sandboxes and resolve them as cold starts
do, instead of backing image mounts with sandbox-scoped volumes. Drives added
to a snapshot launch take reserved Firecracker slots ahead of volumes.
Captures commit them to the manifest while they keep their slots, so relaunch,
fork, and pause/resume need no catalog records. Snapshots with volumes or
attached drives reject explicit attachedDrives, including empty arrays.

aenv start --image-mount and --image-mount-ro now build attachedDrives, and
warm starts use v2. Also mount read-only volumes on cold start through envd.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Comment thread src/api/impls/sandbox.rs
Comment on lines +198 to +200
if let Some(drive) = drives.iter().find(|drive| {
path.starts_with(drive.mount_path()) || drive.mount_path().starts_with(&path)
}) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
This uses raw string prefixes rather than path-component boundaries, so a volume at /workspace/database is rejected as overlapping a drive at /workspace/data (and vice versa), even though the paths are unrelated. The CLI already uses boundary-aware overlap semantics; use the same component-aware predicate here so valid API requests are not rejected.

Suggestion:

Suggested change
if let Some(drive) = drives.iter().find(|drive| {
path.starts_with(drive.mount_path()) || drive.mount_path().starts_with(&path)
}) {
if let Some(drive) = drives.iter().find(|drive| {
mount_paths_overlap(&path, &drive.mount_path())
}) {

Comment thread src/api/impls/sandbox.rs
Comment on lines +822 to +826
let attached_drives: Vec<_> = match timer
.time(
"resolve_attached_drives",
resolve_attached_drives(attached_drives, image_resolver.as_ref()),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The API path validates only duplicate attached-drive mount paths inside resolve_attached_drives; it does not reject ancestor/descendant overlaps such as /mnt/data and /mnt/data/cache. The CLI explicitly enforces non-overlap, and both drives would otherwise be launched with conflicting guest mount points. Add the same component-boundary overlap validation for the resolved drives before combining them with volumes.

Comment thread src/api/impls/sandbox.rs
Comment on lines +843 to +844
let extra_drives_in_snapshot =
body.volume_mounts.is_none() && !snapshot.committed().volume_snapshots.is_empty();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
This flag is still derived only from volume_snapshots. A snapshot can contain committed attached drives without any volumes; when attachedDrives is omitted, those drives are restored into launch_config.extra_drives but this remains false, so initial_guest_drive_mounts_for_snapshot_launch treats them as new and mounts them again after boot (and the launch-slot indexing/accounting follows the wrong path). Base this on whether the snapshot has captured attached drives as well, while preserving the existing volume restoration behavior.

Comment on lines +1848 to +1853
let new_indices = self
.initial_guest_drive_mounts
.iter()
.map(|(index, _)| *index)
.collect::<Vec<_>>();
let is_new_drive = |index: usize| new_indices.contains(&index);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
initial_guest_drive_mounts is also populated by new_with_id for fresh read-only reserved-slot volumes, but this same sandbox object is reused when a paused sandbox resumes. Consequently, such a drive is classified as new here even though it is already part of the captured snapshot; it is prepared with Fresh, skipped by the pre-load slot binding, and only patched after load_snapshot_file. The snapshot's slot path/state therefore is not restored before loading, and the post-load patch can overwrite the captured device. Track drives that are new to the snapshot separately from drives that merely require a post-boot guest mount, and use only the former for resume preparation/binding.

Comment on lines +1848 to +1853
let new_indices = self
.initial_guest_drive_mounts
.iter()
.map(|(index, _)| *index)
.collect::<Vec<_>>();
let is_new_drive = |index: usize| new_indices.contains(&index);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
This performs a linear contains scan for every extra drive in both the preparation callback and slot-binding loop, making resume preparation O(n²). Since the indices are already positional and bounded, use an indexed boolean mask/HashSet (or derive the new-drive range directly) so membership is constant time and the classification is explicit.

Comment on lines +131 to +134
physical_extra_drive_count: attached_drives
.iter()
.filter(|drive| !drive.uses_reserved_slot())
.count(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
extra_drives() later interprets physical_extra_drive_count positionally (index < physical_count), but this count does not ensure that attached_drives is ordered physical-first. For an interleaved input such as [Physical, LaunchSlot, Physical], the count is 2 and the second drive is incorrectly restored as physical while the third is treated as a launch slot, changing Firecracker drive IDs/slot bindings. Normalize the input to physical-first order or validate/enforce that ordering before storing the artifacts.

Suggestion:

Suggested change
physical_extra_drive_count: attached_drives
.iter()
.filter(|drive| !drive.uses_reserved_slot())
.count(),
let mut ordered_drives = attached_drives.to_vec();
ordered_drives.sort_by_key(ExtraDrive::uses_reserved_slot);
let physical_extra_drive_count = ordered_drives
.iter()
.filter(|drive| !drive.uses_reserved_slot())
.count();

Comment on lines +142 to +148
pub fn effective_physical_extra_drive_count(&self) -> usize {
if self.volume_drive_slots == 0 {
self.attached_drives.len()
} else {
self.physical_extra_drive_count
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Using volume_drive_slots == 0 as the legacy discriminator loses the LaunchSlot semantics for any newly constructed or migrated manifest that has reserved-slot drives but no recorded slot count: all attached drives are then returned by extra_drives() as Physical. That can make a later restore bind a launch-time drive to a snapshot-owned slot (or assign the wrong Firecracker ID). Distinguish legacy manifests with an explicit format/field marker, or reject/normalize reserved-slot input when the slot count is zero instead of silently falling back to all-physical.

Suggestion:

Suggested change
pub fn effective_physical_extra_drive_count(&self) -> usize {
if self.volume_drive_slots == 0 {
self.attached_drives.len()
} else {
self.physical_extra_drive_count
}
}
pub fn effective_physical_extra_drive_count(&self) -> usize {
self.physical_extra_drive_count
}

@yingdi-shan
yingdi-shan merged commit dfc6983 into kvcache-ai:main Oct 26, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants